Skip to content

feat: pay bolt11 Lightning invoices via /api/v1/send - #72

Open
xbuddhi wants to merge 2 commits into
mainfrom
xbuddhi/feat/api-invoice-payments
Open

xbuddhi wants to merge 2 commits into
mainfrom
xbuddhi/feat/api-invoice-payments

Conversation

@xbuddhi

@xbuddhi xbuddhi commented Sep 29, 2026

Copy link
Copy Markdown

Continues #66, which was closed automatically when its branch was renamed to xbuddhi/feat/api-invoice-payments. Review history is on #66.

Summary

Adds external bolt11 Lightning invoice payments to the existing /api/v1/send endpoint. When to is a lnbc... payment request (optionally lightning:-prefixed), the bot pays it externally via lnbits instead of doing an internal transfer.

What changed

Invoice payments

  • to matching a bolt11 invoice routes to sendToInvoice; the amount comes from the invoice (amountless invoices are rejected).
  • Same guardrails as internal sends: min/max limits and the per-wallet admin-approval threshold (amounts above it go through the Telegram approval flow).
  • Idempotency on the invoice payment hash: a short lock for concurrent requests plus a durable lock so an invoice can have only one open approval.

Settlement confirmation

  • lnbits answers once it hands the payment to its backend, not when it settles, so each payment is followed to a final state (settlement_timeout, default 60s, kept inside the 90s server write timeout).
  • 200 settled (with preimage and real fee), 400 failed (with payment_hash), or 202 pending (do not retry).
  • New GET /api/v1/send/payment/{payment_hash} to resolve a pending payment; approved payments that stay unresolved are recorded as in_flight, never expired.

Concurrent payments

  • Several invoices can be paid in parallel from one wallet. Each payment reserves amount + 2% fee reserve against the available balance (wallet − pots − other in-flight payments) until lnbits accepts it, so parallel payments cannot spend pot funds.

Security / review fixes

  • HMAC signatures are single use, and future timestamps are rejected (replay protection).
  • payment_hash is validated (64 hex chars) before it reaches an lnbits URL.
  • Settlement polling and analytics share one lnbits rate limiter.
  • Fee reserve uses integer math; approvals re-check the available balance.
  • Duplicate-approval check fails closed on storage errors; a failed Telegram approval delivery returns an error instead of a false 202.
  • The Telegram approve button expires with the pending transaction (24h), matching /api/v1/send/status.

lnbits 1.x

  • Payment time accepts RFC3339 strings; payment status (success/pending/failed) is read. Branch is up to date with main (lnbits 1.x account API).

⚠️ Behaviour changes for other integrations

  • All HMAC wallet endpoints (/send, /userbalance, /usertransactions, /referral/lookup, status endpoints) now reject a reused signature (401 Request already processed). Clients must sign every request, including retries, with a fresh X-Timestamp.
  • Approvals older than 24h can no longer be approved.

Docs

  • Provider guide: docs/invoice-payments-api.md
  • Updated docs/referral-api.md and README.md.
  • New config: api.send.settlement_timeout (seconds, default 60).

Testing

  • go build ./... clean
  • go test ./internal/lnbits/ ./internal/utils/ pass
  • Settlement classification, payment-hash validation, fee reserve, balance reservation (incl. 50 concurrent requests) and approval expiry are unit tested. The telegram package tests run from the repo root (i18n loads translations/ relative to the working directory).

🤖 Generated with Claude Code

@0xMadie 0xMadie left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BackendBanda Code Review — PR #72: bolt11 Lightning invoice payments

Review effort: 4/5 | Reviewer: backend-banda | Date: 2026-10-14


Summary

Solid, well-structured feature. The core correctness concerns (double-spend, false settlement confirmation, replay attacks) are all addressed. A few things need fixing or a decision before merge.


✅ What's good

HMAC single-use enforcement (middleware.go)

  • signatureSet.claim() with in-memory map + TTL = correct approach. Eviction on each call avoids unbounded growth. The TTL is 2 * tolerance seconds — wide enough to cover the full valid window.
  • Future-timestamp rejection (age < -tolerance) closes the captured-request replay window. Breaking change called out clearly in PR description.

Settlement polling (settlement.go)

  • WaitForOutgoingPayment → polling loop with exponential backoff (500ms → 3s cap × 1.5). Correctly stays inside the 90s write timeout via settlementBudget trimming against elapsed time since request start.
  • classifyPayment is appropriately conservative: paid:false with no status → PaymentStateUnknown, never failure. This avoids falsely telling callers their sats came back.
  • Settlement preimage zero-value guard (strings.Trim(preimage, "0") == "") is correct — lnbits returns all-zero preimage for unsettled payments.
  • Test coverage of all lnbits response shapes including public-key response (no status) → PaymentStateUnknown. Good.

Balance reservation under concurrency (balance_reservation.go)

  • Per-sender sync.Mutex serialises the check-then-reserve step. once.Do in Release() prevents double-release. The concurrent stress test (50 goroutines × 100 sat from 1000 balance → exactly 10 granted) is clean and passes.
  • Reservation released immediately after lnbits.Pay accepts, not after settlement polling: correct — lnbits already reflects the deduction once accepted, so holding longer would double-count.

Idempotency (two-layer)

  • Short MemoCache lock (concurrent requests, ~5 min) + durable InvoiceApprovalLock in Bunt (24h, for re-submitted approvals) — the gap between them is documented and addressed.
  • Invoice payment hash validated as ^[0-9a-f]{64}$ before use in lnbits URL. ✅

Approval flow (api_approval.go)

  • Per-request apiApprovalMarkup avoids the race between concurrent approval messages overwriting shared callback data.
  • Expiry check at approve-time (time.Now().After(approvalData.expiresAt())) with backwards-compat fallback for old approvals (CreatedAt + PendingTxExpiry). Correct.
  • Failed Telegram delivery → immediate error + rollback (abandonPendingTransaction), not a silent 202. ✅

🔴 Must-fix before merge

1. signatureSet is process-local — multi-instance deployments allow replay

var usedSignatures = &signatureSet{...} lives in Go process memory. If the bot runs as more than one instance (e.g. two pods, or a restart mid-tolerance-window), a captured signature can be replayed against the second instance.

For a single-process bot this is fine; for any scaled deployment it is not. Either:

  • Document explicitly that the bot must run as a single instance, and add a startup log warning if that changes, OR
  • Move the seen-signature store to Bunt (same pattern as InvoiceApprovalLock) with a TTL-based cleanup.

Action needed: at minimum, a comment in signatureSet stating the single-instance assumption. If multi-instance is in the roadmap, make it Bunt-backed now.

2. MemoCache TTL for the concurrent invoice lock is undocumented / unclear

s.MemoCache.SetNX(lockKey, "locked") is used without a TTL here (in sendToInvoice). The comment says "short lock...expires after 5 minutes" but I don't see where that TTL is set. If MemoCache entries never expire, the lock is permanent: a request that dies after SetNX but before defer Delete (e.g. server crash, panic) would permanently block that invoice from being paid.

Please confirm: does MemoCache have a global TTL? If not, add an explicit TTL to this SetNX call (e.g. 10 minutes), or document the GC/restart recovery path.

3. SendPaymentStatus (GET /api/v1/send/payment/{hash}) has a zero-timeout single lookup

settlement := s.Bot.Client.WaitForOutgoingPayment(*fromUser.Wallet, paymentHash, 0)

With timeout=0, WaitForOutgoingPayment does at most one rate-limited check. If lnbits is slow or the rate limiter is busy, this could return PaymentStateUnknown for a payment that would resolve within 1s. The caller gets unknown and has to retry anyway — so the endpoint is not saving them a round trip.

Suggest: a short timeout (e.g. 5s) so a single poll has a chance to land, while still returning promptly. Or document that callers should expect unknown and retry with backoff.


🟡 Non-blocking observations (address in follow-up or now, author's call)

4. lnbc... / lightning: prefix lowercased before invoice check

strings.ToLower(req.To) before lightning.IsInvoice — fine for the prefix, but bolt11 invoices themselves use bech32 which is case-insensitive. decodepay handles upper/lower, so this is safe. Just noting it's intentional.

5. Approval-flow invoice path: reservation acquired in approveAPITransactionHandler but not in executeApprovedInvoicePayment?

In the non-invoice approval path, ReserveBalance is called before the transfer and deferred-released. In executeApprovedInvoicePayment, I don't see an equivalent reservation — the approval handler reserves, but does it hold through the pay call? Please verify the reservation is not released before lnbits.Pay returns, or add one inside executeApprovedInvoicePayment.

6. payment_hash in pending transaction ID is included in plain text

id := fmt.Sprintf("pending-%s-invoice-%s-%d", ...) — the payment hash is part of the bunt key. Payment hashes are public on the Lightning network so this is not a secret, but if bunt keys are ever logged or exposed, it ties a username to a specific payment. Minor.

7. Test for SendPaymentStatus endpoint is missing

The settlement_test.go covers classifyPayment well. The HTTP handler itself (SendPaymentStatus) has no test. At minimum an integration-style test that exercises the auth check and the hash validation would catch regressions.


Verdict

Approve with blocking comments resolved. Issues 1 and 2 need a documented answer or a code fix; issue 3 needs at minimum a comment. Non-blocking items can go in a follow-up.

go build ./... and go test ./... are reported clean; I cannot run them locally (no repo access) but the test coverage on the new packages is solid.

⚠️ Note for merge: this PR changes the HMAC auth contract for ALL existing /send, /userbalance, /usertransactions, and status callers. Any client that retries with the same timestamp+signature will receive 401 Request already processed. Make sure existing integrations (CeyFi, DCA bot) sign fresh timestamps on every request including retries before merging to main.

— backend-banda (T-0025)

@xbuddhi
xbuddhi force-pushed the xbuddhi/feat/api-invoice-payments branch from 9136a98 to b89da36 Compare October 16, 2026 12:40
@coderabbitai

coderabbitai Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 59258242-3eeb-40d3-bb63-18bc5c770628
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

/api/v1/send now accepts a bolt11 invoice (lnbc..., optionally with a
lightning: prefix) as `to` and pays it externally. The amount comes from
the invoice; amountless invoices are rejected.

- Validate invoice amount against min/max limits and the admin approval
  threshold, and require a 2% routing-fee reserve.
- Reserve sender balance per user so concurrent sends cannot overspend.
- Idempotency on the payment hash: short in-memory lock plus a durable
  24h approval lock, so the same invoice is never paid twice.
- Wait for the payment to settle (settlement_timeout) before reporting
  success; unsettled payments return 202 pending, never a false failure.
- Add GET /api/v1/send/payment/{payment_hash} to resolve in-flight
  payments (waits up to 5s per call).
- Telegram approval flow supports invoices, with expiry checks and
  rollback when the approval message cannot be delivered.
- HMAC: each signed request is single-use and future timestamps are
  rejected. Clients must sign a fresh timestamp on every retry. The
  used-signature set is in memory, so the bot must run as one instance.
- Docs: docs/invoice-payments-api.md and updated referral-api.md.
- HMAC: valid, expired, future, wrong secret, tampered body/path,
  missing headers, replay rejected, fresh-timestamp retry allowed.
- /send: bad JSON, auth, unknown wallet, undecodable and amountless
  invoices, amount taken from the invoice for min/max checks.
- /send/payment: hash validation and auth.
@xbuddhi

xbuddhi commented Oct 17, 2026

Copy link
Copy Markdown
Author

@0xMadie thanks for the review. Fixes are in b89da36 (squashed onto latest main) and f6f479d (tests).

Must-fix

  1. signatureSet is process-local: added a comment on signatureSet saying the bot must run as a single instance, and that the set has to move to Bunt before running more than one. Multi-instance isn't on the roadmap now, so I kept it in memory.
  2. MemoCache TTL: this was already safe. MemoCache is created with a global 5 min TTL (main.go:112, utils.NewCache(time.Minute * 5)), and SetNX sets that expiry on every entry, so a crashed request can't lock an invoice forever. Added a comment at the SetNX call.
  3. SendPaymentStatus zero timeout: now waits up to 5s (paymentStatusWait) before answering. Docs updated: if it is still pending/unknown, poll again with backoff.

Non-blocking

  1. Lowercasing is intentional, as you noted. bech32 is case-insensitive.
  2. Verified: executeApprovedInvoicePayment takes its own ReserveBalance (with the fee reserve) and holds it until lnbits.Pay returns. It is released before the settlement wait.
  3. Left as is. Payment hashes are public, and the key is only used internally.
  4. Added tests:
    • internal/api/middleware_test.go: valid/expired/future timestamps, wrong secret, tampered body/path, missing headers, replay rejected, fresh-timestamp retry allowed, signatureSet expiry.
    • internal/api/send_test.go: /send invoice validation (undecodable, amountless, min/max use the invoice amount, lightning: prefix/upper case) and /send/payment/{hash} hash validation + auth.

Merge note: agreed. CeyFi and the DCA bot must sign a fresh timestamp on every request, including retries, before this is deployed.

@xbuddhi
xbuddhi requested a review from rayaanr October 17, 2026 02:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request Review effort 4/5

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants